在開發應用程式時,常會因應需求開發一表單元件。然而,,除非在此元件就已經內含使用 Form Field 外,是無法在應用端中直接把此表單元件放在 Material 的 Form Field 內。
在撰寫表單元件時,會去實作 ControlValueAccessor 介面,並設定 NG_VALUE_ACCESSOR 提供者。
type TaskType = { first: string, second: string };
@Component({
  selector: 'app-task-type-select',
  ...
  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TaskTypeFormComponent), multi: true }],
})
export class TaskTypeFormComponent implements OnInit, ControlValueAccessor {
  protected readonly form = new FormGroup({
    first: new FormControl<string | null>(null),
    second: new FormControl<string | null>({ value: null, disabled: true }),
  });
  onChange!: (_: TaskType) => void;
  onTouched!: () => void;
  writeValue(data: TaskType): void {
    if (data) {
      this.form.patchValue(data);
    } else {
      this.form.reset();
    }
  }
  registerOnChange(fn: () => void): void {
    this.onChange = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
}
若要讓我們定義的元件可以放在 Form Field 裡,就需要去實作 MatFormFieldControl<TaskType> 介面,其泛型型別就是要回傳的型別。其次,在提供者方面,針對 MatFormFieldControl 取代 NG_VALUE_ACCESSOR 來進行設定。
@Component({
  ...
  providers: [{ provide: MatFormFieldControl, useExisting: forwardRef(() => TaskTypeFormComponent), multi: true }],
})
export class TaskTypeFormComponent implements OnInit, MatFormFieldControl<TaskType>, ControlValueAccessor {
  ...
}
在實作 MatFormFieldControl 介面時,我們需要定義下面需要的屬性。
此屬性定義 Form Field 所使用的唯一性編號資訊
private static nextId = 0;
@HostBinding()
id = `task-type-select-${TaskTypeSelectComponent.nextId++}`;
在 Form Field 內表單有更新時,需要利用此屬性來發送變動資訊。需要記得要在元件銷毀一併把此屬性結束。
readonly stateChanges = new Subject<void>();
ngOnDestroy(): void {
  this.stateChanges.complete();
}
用來定義描述文字、必填與停用等資訊,這些屬性在有變更時,就需要觸發 stateChanges.next() 。
readonly _placeholder = input<string>('', { alias: 'placeholder' });
get placeholder(): string {
  return this._placeholder();
}
readonly _required = input<boolean, string | boolean>(false, {
  alias: 'required',
  transform: booleanAttribute,
});
get required(): boolean {
  return this._required();
}
readonly _disabled = input<boolean, boolean | string>(false, {
  alias: 'disabled',
  transform: booleanAttribute,
});
get disabled() {
  return this._disabled();
}
constructor() {
  ...
  effect(() => {
    this._placeholder();
    this._required();
    this._disabled();
    untracked(() => this.stateChanges.next());
  });
  effect(() => {
    const fn = this._required()
      ? this.formControl.addValidators(Validators.required)
      : this.formControl.removeValidators(Validators.required);
    untracked(() => fn);
  });
  effect(() => {
    const fn = this._disabled()
      ? this.formControl.disable()
      : this.formControl.enable();
    untracked(() => fn);
  });
  ...
}
在提供者設定上,我們用 MatFormFieldControl 取代 NG_VALUE_ACCESSOR 後,需要把 ngControl 屬性中的 valueAccessor 設定成此元件實體。
ngControl = inject(NgControl, { optional: true, self: true });
constructor() {
  if (this.ngControl !== null) {
    this.ngControl.valueAccessor = this;
  }
  ...
}
最後主要使用 focused、empty、errorState 等屬性來定義 Form Field 表單狀態。
private readonly _focused = signal(false);
get focused(): boolean {
  return this._focused();
}
get empty(): boolean {
  return (
    this.formControl.value === undefined || this.formControl.value === null
  );
}
get errorState(): boolean {
  return this.formControl.invalid;
}
@HostBinding('class.floating')
get shouldLabelFloat(): boolean {
  return this.focused || !this.empty;
}
private readonly fm = inject(FocusMonitor);
private readonly elRef = inject<ElementRef<HTMLElement>>(ElementRef);
constructor() {
  ...
  this.fm.monitor(this.elRef.nativeElement, true).subscribe((origin) => {
    this._focused.set(!!origin);
    this.stateChanges.next();
  });
}
如此一次,我們就可以把自訂的表單元件使用在 FormField 內。
<mat-form-field>
  <app-task-type-select [formControl]="formControl" />
</mat-form-field>